iT邦幫忙

2022 iThome 鐵人賽

DAY 29
2
Modern Web

一次打破 React 常見的學習門檻與觀念誤解系列 第 29

[Day 29] 一次弄懂 React hooks 的運作原理與設計思維(下)

  • 分享至 

  • xImage
  •  

Hooks 的誕生是為了解決什麼問題

在深入 hooks 的設計脈絡之前,我們得先來談談 hooks 的誕生究竟是為了解決什麼問題。首先,hooks 是綁定配合 function component 使用的。這是因為在一律重繪的渲染策略之下,原本 class component 這種偏物件導向的設計會有很多概念上的衝突,例如 class component 裡的成員方法無法參與資料流的變化、this.propsthis.state 在非同步事件中可能拿到錯置的資料之類的等等問題…在本系列文前面的篇幅中有許多深入的介紹,這邊就不再贅述太多。

為了能夠更貼近一率重繪、immutable 等核心設計的概念,React 決定往更加靠攏 functional programming 的方向去發展。在有了 function component 之後,React 還必須設計一套全新的機制與 API 來解決幾個重要的問題:

讓 function component 擁有狀態

Function component 能夠讓 React 的每次 render 能夠獨立不互相干擾,你不會需要再擔心非同步事件從 this.props 讀取資料可能導致的問題。然而 UI 的本質是需要能夠擁有「狀態」的,而且一個 component 中有可能同時有多種狀態。同時這個 API 還得滿足支援多種狀態相互引用傳值的需求、同時又要避免命名衝突等問題。我們需要一個有足夠的彈性來與開發者互動,同時又能在內部維護 fiber node 的 API 設計。

Components 之間的邏輯重用

讓不同的 components 之間重用邏輯一直是前端的開發當中相當重要的需求。不過事實上,在整個 class components 的時代 React 都從來沒有推出過官方的 components 邏輯重用 API。這是因為在 class component 的寫法中,狀態與生命週期都必須直接寫在一個 component 中才能定義,並且同一個功能你可能會需要在許多生命週期都放入邏輯,因此你其實很難把它們抽出來在多個 components 之間共用。

在 class component 的時代中,也有許多社群提出的 patterns 來繞圈解決邏輯重用的問題,主流的像是 higher order component 以及 render props。不過它們都無法完美的解決所有問題,仍然有命名衝突、依賴不透明…等等不足之處。


Hooks API 的設計思維與脈絡

Hooks 的目標,是想要配合 function component 設計一套能夠定義並管理狀態並且方便共用邏輯的 API,同時解決幾個過去的方案會遇到的問題:

  • 避免命名衝突
  • 依賴透明,被重用的不同邏輯們之間可以自由拆分、組合與調用
  • 避免污染 render 結果的 React element

為此,hooks API 採用了以下一些設計思路:

Function base

當我們想共用一個功能的邏輯或流程,以 component 為單位來進行包裹的方式(像是 higher order component)可能會遇到一些問題,例如兩段為了重用邏輯而寫的 component,裡面都有名為 name 的 prop,此時如果同時套用到同一個目標的 component 上時候,就有可能遇到命名衝突問題。另外這樣做也會讓這兩段邏輯之間無法彈性的互動,只能是 A 覆蓋 B 或 B 覆蓋 A,兩者擇一。

因此能讓邏輯與流程能夠以最大的彈性被拆分與調用的形式,仍然是函式。函式可以自由的設計參數與回傳值,也能很好的自由拆分與組合,這是 hooks 被設計成都是函式的一個主要原因:

function App() {
  const [page, setPage] = useState(1);
  const [rowsPerPage, setRowsPerPage] = useState(10);
  const [productList, setProductList] = useState([]);
  const [productListLoading, setProductListLoading] = useState(false);

  useEffect(
    () => {
      let ignore = false;
      setProductListLoading(true);  

      const startIndex = (page - 1) * 10;
      const endIndex = (page * 10) - 1;
      ProductAPI.queryList({ startIndex, endIndex })
        .then((data) => {
           if (!ignore) {
             setProductList(data);
             setProductListLoading(true);
           }
         });

      return () => {
        ignore = true;
      };
    },
    [page, rowsPerPage]
  );

  // ...
}

我們還可以把上面範例中的「控制 pagination options」以及「query product list API」兩段邏輯抽出來,以方便共用:

function usePaginationOptions() {
  const [page, setPage] = useState(1);
  const [rowsPerPage, setRowsPerPage] = useState(10);

  const startIndex = (page - 1) * 10;
  const endIndex = (page * 10) - 1;
  
  return {
    page,
    rowsPerPage,
    startIndex,
    endIndex,
    setPage,
    setRowsPerPage,
  };
}

function useProductListQuery({ startIndex, endIndex }) {
  const [productList, setProductList] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(
    () => {
      let ignore = false;
      setLoading(true);

      const startIndex = (page - 1) * 10;
      const endIndex = (page * 10) - 1;
      ProductAPI.queryList({ startIndex, endIndex })
        .then((data) => {
           if (!ignore) {
             setProductList(data);
             setLoading(false);
           }
         });

      return () => {
        ignore = true;
      };
    },
    [page, rowsPerPage]
  );

  return { productList, loading };
} 

function App() {
  const { startIndex, endIndex } = usePaginationOptions();
  const { productList, loading } = useProductListQuery({
    startIndex,
    endIndex
  });
  
  // ...
}

在抽出成 custom hooks 之後,不僅我們主要的 component 中裡的邏輯簡化了很多,也能很方便清楚的讓這些 hooks 之間進行資料的傳遞。這樣的 hooks API 設計幫助我們很好的滿足邏輯之間依賴透明的目標。

並且由於 hooks 的調用是在 render 過程中發生的,這些狀態與邏輯的定義載體不需要依賴於獨立的 component,因此我們無論你在一個 component 中調用了多少 hooks,都不會污染到 render 出來的 React element 結構,這能讓與畫面渲染無關的邏輯與畫面本身分離,提升我們的 components 可讀性。

依賴固定調用順序

然而,以函式的形式定義流程以及邏輯雖然很直覺方便,但是定義狀態資料則有點微妙。如果一個用來定義狀態資料的 hook,我在某次 render 時有調用,但卻在其它次的 render 時沒有調用,那這到底代表什麼意思?是這個狀態再也用不到了,應該直接移除?如果後面的 render 中再次出現的話那之前留存資料應該還在嗎?從各種角度上來說這都是相當不直覺且很容易讓人誤解其行為。

為此,我們必須保證 component 裡的所有 hooks 在每一次 render 時都會固定的被調用到。關於細節可上參考前一篇章中的解析:[Day 28] 一次弄懂 React hooks 的運作原理與設計思維(上)

然而為什麼同一個 component 中的多個 hooks 是設計成依賴順序來存放並區分的?大多數人的第一直覺應該是會想要以一個唯一的 key 來定義它們:

// ❌ 注意,以下不是真實的 hooks API,只是假設的 API 設計:
// useState(stateKey, defaultValue)

const [name, setName] = useState('name', '');
const [surname, setSurname] = useState('surname', '');
const [width, setWidth] = useState('width', 0);

命名衝突問題

然而這種基於自定義 key 的設計會有個難以避免的問題 — 命名衝突

你無法在同一個 component 裡調用兩次 key 皆為 'name'useState。如果你的這些狀態與邏輯僅定義在一個 component 裡的話這種情況可能還在可以控制的範圍內,畢竟你可以自己在 component 中避免重名。然而,如果還要考慮到重用問題的話,每當你在 custom hooks 內定義 state,就可能導致重用了這個 custom hook 的 component 壞掉 — 因為在 component 內有可能也定義了相同 key 的 state。

而依賴調用順序的方式,基本上就是讓 hooks 的 key 都是一種順序性的 index,如果這個 hook 在 render 中第三個被調用的 hook,那只要它在往後的 render 中也一直維持是第三個被調用的 hook,我們就能保持這個機制運作正常。

鑽石問題

基於 key 的 hooks 設計也會導致一個在程式設計領域惡名昭彰的問題 — 鑽石問題,又被稱為多重繼承問題菱形繼承問題。這其實是命名衝突問題的延伸進階版,讓我們以一個範例來解釋:

在以下的範例中,我們想要在遊戲資料中定義「玩家」以及「怪物」兩種類型,而它們兩者都有「位置座標」這種相同的資料概念,我們想要重用這個部分:

function usePosition() {
  // ❌ 注意,這裡是假想的 hooks API,指定這個 hook 的 key 為 'positionX'
  const [x, setX] = useState('positionX', 0); 

  // ❌ 注意,這裡是假想的 hooks API,指定這個 hook 的 key 為 'positionY'
  const [y, setY] = useState('positionY', 0);

  return { x, setX, y, setY };
}

function usePlayer() {
  const posotion = usePosition();
  
  // ...其他 player 才會有的資料或方法

  return { ....., posotion };
}

function useMonster() {
  const posotion = usePosition();
  
  // ...其他 monster 才會有的資料或方法

  return { ....., posotion };
}

// component
function GameApp() {
  const player = usePlayer();
  const moneter = useMonster();
	// ...
}

在上面這段 React 程式碼中,usePlayeruseMonster 這兩個 custom hooks 的內部都重用到 usePosition 這個 custom hook,而 usePosition 裡面以 key 的方式定義了兩種 state positionX 以及 positionY。而此時當我們的 GameApp component 中同時調用了 usePlayer 以及 useMonster 兩種 hooks 時,鑽石問題就產生了:

https://i.imgur.com/i19xkZI.png

當我們同時在一個 component 中去調用 usePosition 兩次時,它們兩者會分別在 component 裡都嘗試註冊名為 positionXpositionY 的 hooks,這會導致命名衝突問題

而如果是基於 hooks 在 component 裡的固定調用順序,則可以很自然的解決了這個問題:

https://i.imgur.com/XkGcQxY.png

純粹的函式調用並不會有鑽石問題,它們會自然的形成樹狀結構。而 component 只需要以這些 custom hooks 層層的調用 stack 展開後的調用順序,來區分並追蹤 hooks 的狀態資料即可。這樣的設計能讓我們可以對於命名衝突的惡夢說再見。

useEffect 的資料流同步化取代生命週期 API

我們在系列文的前面篇章中,曾花了許多篇幅來解析 useEffect 的概念與正確用途。其中曾提到過一個重要的觀念:function component 沒有提供生命週期的 API,只有 useEffect 用於「從資料同步到 effect 的行為與影響」。

在這裡我們想要進一步探究的是,為什麼 React 在 hooks API 中做出這樣的設計決策,以往的 class component 暴露生命週期 API 給開發者的方式出了什麼問題?

首先,事實上我們在 component 中會執行的副作用,絕大多數都是為了讓 React 內部的某些資料與React 外的事情同步。例如最常見的就是以某些參數去請求伺服器端的 API,並取回某些資料。而這些與外部同步並取回資料的動作,又通常會需要當不再運行時進行一些清理或還原的處理:

componentDidMount() {
  OrderAPI.subscribeStatus(
    this.props.id,
    this.handleStatusChange
  );
}
 
componentWillUnmount() {
  OrderAPI.unsubscribeStatus(
    this.props.id,
    this.handleStatusChange
  );
}

這個範例中的 class component 會以 props.id 來在 componentDidMount 時訂閱指定訂單的狀態,並在 componentWillUnmount 去取消這個訂單狀態的訂閱。看起來似乎一切都很好。

然而當 component 以新的 props.id 進行了 re-render 時,這個 component 將不會自動以新的 id 來重新訂閱對應的訂單狀態資料,也不會正確的取消原本那個訂單的狀態訂閱,進而導致 memory leak 等問題。而忘記正確的處理 componentDidUpdate 正是 class component 中常見的 bug 來源。

componentDidMount() {
  OrderAPI.subscribeStatus(
    this.props.id,
    this.handleStatusChange
  );
}

// 加上 componentDidUpdate 的處理以解決 bug
componentDidUpdate(prevProps) {
  // 從先前的訂單 id 取消訂閱
  OrderAPI.unsubscribeStatus(
    prevProps.id,
    this.handleStatusChange
  );

  // 訂閱下一個訂單 id
  OrderAPI.subscribeStatus(
    this.props.id,
    this.handleStatusChange
  );
}
 
componentWillUnmount() {
  OrderAPI.unsubscribeStatus(
    this.props.id,
    this.handleStatusChange
  );
}

Class component 中原有的生命週期 API 設計,潛移默化的讓身為開發者的我們習慣將「didMount」、「didUpdate」、「willUnmount」的情境拆開來思考,我們必須自己去考慮要在哪些生命週期中做哪些動作,才能完成這個「以 this.props.id 來訂閱訂單的狀態資料」持續同步化的效果。只要我們遺漏處理了其中任何一種情況,component 的行為就會是有 bug 的。然而當應用程式日漸龐大的情況下,身為凡人的我們通常很難完全沒有錯漏,這對開發者縝密的思維以及細心程度的要求非常高。

另外,上面這些 class component 生命週期 API 的程式碼也很難被抽出並重用在其它 components 中。你可以想像如果有兩個可重用的功能都會需要在 componentDidMountcomponentDidUpdatecomponentWillUnmount 裡添加邏輯,那麼當我們想要在同一個 component 中同時添加這兩個功能上去時,它們就非常容易打架並弄壞對方。

而類似的邏輯在 function component 中其實只需要一個 useEffect 就能搞定,只需要描述 effect 的同步邏輯,以及清除這個同步所造成的副作用的 cleanup,就能一次搞定 mount、update、unmount 等情境。同時,由於它只是一個函式,因此我們可以很輕易地將它抽出成一個 custom hook 來重用,而不需要在定義時與其他功能的生命週期 API 打架:


function useOrderStatusSubscribe() {
  useEffect(() => {
    OrderAPI.subscribeStatus(props.id, handleChange);
    return () => {
      OrderAPI.unsubscribeStatus(props.id, handleChange);
    };
  });
}

這樣重視「同步結果」而非「執行細節或時機」的 API 設計,讓我們在處理副作用時也能更享受到「宣告式」風格的好處。這可以使身為開發者的我們能更好的專注在商業邏輯本身,而不是 component 的內部運作生命週期。


參考資料


2024/2 更新 - 實體書平裝版本預購

在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~

《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》

目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:

天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695

博客來(平裝版):
https://www.books.com.tw/products/0010982322

momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845


上一篇
[Day 28] 一次弄懂 React hooks 的運作原理與設計思維(上)
下一篇
[Day 30] 一次打破 React 常見的的學習門檻與觀念誤解:系列文總結以及完賽感言
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言